Skip to content

Keep a host function in the engine's own realm after a second realm exists - #2893

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:jint/original-intrinsics-clobber
Aug 2, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:jint/original-intrinsics-clobber

Conversation

@lahma

@lahma lahma commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

The bug

var engine = new Engine(options => options.AddLazyGlobal(
    "log", static e => new ClrFunction(e, "log", static (_, _) => JsValue.Undefined)));

engine.Evaluate("new ShadowRealm();");
engine.Evaluate("log instanceof Function");   // false

An engine keeps the first Intrinsics it builds in Engine._originalIntrinsics so that constructing a host function does not need a realm passed in — the public ClrFunction(Engine, ...) constructor reads _originalIntrinsics.Function.PrototypeObject.

Intrinsics's constructor assigned that field unconditionally, and Intrinsics is constructed once per realm. Constructing a ShadowRealm — or $262.createRealm() under test262 — therefore replaced it, and every host function built afterwards took its prototype from a realm the surrounding script cannot reach.

Why it is reachable rather than theoretical

A lazily registered global builds its value on first read, which can easily be after a script has constructed a shadow realm. The internal ClrFunction(Engine, Realm, ...) overload exists precisely to dodge this for in-box shape materialization; the public constructor had no such protection, and it is the one embedders use.

The fix

_engine._originalIntrinsics ??= this;

The first intrinsics an engine builds are the ones InitializeHostDefinedRealm created — the engine's principal realm, and the realm a host means when it builds a function against the engine. Every later set belongs to a second realm and must not displace it.

Tests

Jint.Tests.PublicInterface/HostClrFunctionRealmTests.cs, asserting what a script can observe (PrototypeObject is internal, which is the right constraint for a test about host-visible behaviour):

  • a host function built before any other realm belongs to the main realm — passes before and after, so it pins the baseline;
  • one built after a ShadowRealm still does — fails without this change;
  • a lazily registered global materializing after a ShadowRealm is still instanceof Function — the reachable shape, and also fails without this change.

I confirmed all three fail on main and pass with the fix. Jint.Tests 4693 and Jint.Tests.PublicInterface 1266 pass on net10.0 and net472.

Where it came from

Reviewing where per-engine host state belongs (#2891). _originalIntrinsics is an ad-hoc stand-in for "this engine's principal realm", and it did not hold — which is part of the argument for naming that concept properly.

🤖 Generated with Claude Code

…xists

An engine keeps the first Intrinsics it built so that constructing a host function does
not need a realm passed in — the public ClrFunction(Engine, ...) constructor reads
_originalIntrinsics.Function.PrototypeObject for the prototype. That assignment was
unconditional, and Intrinsics is constructed once per realm.

So constructing a ShadowRealm (or $262.createRealm under test262) replaced it with the
new realm's intrinsics, and every host function built afterwards took its prototype
from a realm the surrounding script cannot reach. `log instanceof Function` came back
false for a global whose delegate happened to materialize after a script had
constructed a shadow realm — which a lazily registered global does by design, on first
read.

Assign only when the slot is still empty. The first Intrinsics an engine builds are the
ones InitializeHostDefinedRealm created, which is the engine's principal realm and the
one a host means when it builds against the engine; every later set belongs to a second
realm and must not displace it.

Found while reviewing where per-engine host state belongs (sebastienros#2891): the field is an
ad-hoc stand-in for "the principal realm", and it did not hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit fceef84 into sebastienros:main Aug 2, 2026
5 checks passed
@lahma
lahma deleted the jint/original-intrinsics-clobber branch August 2, 2026 13:28
legrab added a commit to legrab/pocok that referenced this pull request Aug 18, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.15.3 to
4.16.0.

<details>
<summary>Release notes</summary>

_Sourced from [Jint's
releases](https://github.com/sebastienros/jint/releases)._

## 4.16.0

Jint 4.16.0 is a **correctness- and reliability-focused release**:
alongside asynchronous module loading, proper tail calls and four new
iterator built-ins, a pre-tag review swept the whole engine and fixed
what it found — including long-standing defects that predate this cycle.
**No option defaults changed.** Behaviour changes to note up front:
`JSON.stringify` and other machine-readable output now format
invariantly under every host culture — under Swedish or Finnish locales
on .NET 8+ it used to emit a Unicode minus sign no JSON parser accepts;
`JSON.parse` now rejects trailing commas as the grammar requires; bare
identifiers at global scope resolve through the global's prototype chain
per spec; `IModuleLoader.Resolve` is consulted once per (referrer,
specifier) pair, so a loader using it as a per-import access-control
checkpoint should move the check to `LoadModule`; and an inconsistent
sort comparator now finishes with an implementation-defined order on
every target framework instead of hanging (net462/netstandard) or
throwing a CLR exception at script (net8+).

### Highlights

**Proper tail calls (#​2975).** Strict-mode calls in tail position reuse
their frame, so `"use strict"` tail recursion runs in constant stack —
the first ES2015 PTC implementation among the .NET engines.

**Asynchronous module loading (#​2872).** `IAsyncModuleLoader` and the
`AsyncModuleLoader` template let a host fetch module source over I/O
without blocking a thread; `Engine.Modules.StartImport` returns an
operation a game loop drives via `ProcessTasks()`, and `ImportAsync`
awaits without holding a thread. The spec's load phase now exists as
written, a warm-cache async loader keeps the blocking `Import` fully
synchronous, and the blocking drain wakes on a work-arrived signal
instead of polling. A module served over a transport keeps its whole url
as `Module.Location` so its own relative imports resolve, a deferred
namespace evaluates its module instead of exposing uninitialized
bindings, and an import abandoned by a global snapshot restore reports
itself faulted instead of polling forever.

**The process no longer dies for recoverable reasons.**
`Options.LimitRecursion` used to kill the host process for most useful
limits — the constraint fired, and the unwind itself overflowed the
stack; exception filters now let it unwind ~7× deeper. The new opt-in
`Options.Constraints.StackOverflowGuard` converts unbounded recursion —
reachable through eighteen distinct routes, `new`, accessors, coercions
and Proxy traps included — from a process kill into a catchable
`RangeError`, exempting strict tail calls, which grow no stack. And a
family of CLR exceptions that escaped `engine.Evaluate` past every
script `catch` are now proper JavaScript errors or correct results:
sorting with an inconsistent comparator, destructuring with a
function-valued default (`const { onChange = () => {} } = opts`),
`toLocaleString` outside `DateTime`'s range, typed-array
`defineProperty` without a value, `DataView` reads at 2³¹,
`String.replace` `$'` with a lying exec, and the first instant of year
10000.

**New built-ins.** `Iterator.prototype.join`, `chunks`, `windows` and
`includes`; `take`/`drop` now throw `RangeError` for a finite limit
above 2^53−1 per the updated proposals.
`Intl.Locale.prototype.getCollations` reports CLDR-cited collation data
that `Intl.Collator` accepts in full, a malformed `collation` option is
a `RangeError`, and `Intl.supportedValuesOf("collation")` derives from
the same lists so the three can never drift.

**Conformance, from a review that ran what the suite does not.** Two of
the fixed defects had test262 coverage only under the never-generated
`staging/` directory, and several had none at all: `parseInt` strips the
sign before testing for a hex prefix, so `parseInt("-0x10")` is −16; a
suspended `finally` no longer swallows a pending `break`/`continue`; a
Proxy (or exotic host object) as the global's prototype answers bare
identifiers through its `get` trap; `Date.prototype.toISOString` emits
the spec's six-digit expanded year and round-trips through `Date.parse`
in every spelling including year 0; iterator helpers close their
receiver exactly once and only when the spec says so, and carry their
own `@@​toStringTag`; `Map`/`Set` `size` is the prototype accessor the
spec defines rather than a phantom own property; a Proxy's
`defineProperty` trap receives the partial descriptor the caller wrote;
a string's `@@​iterator` is read once, with the primitive as receiver;
`Array.prototype.join` re-asks the array when a side effect fills a hole
mid-join; a direct eval reaches the enclosing function's `arguments` in
both modes; and `Temporal.Now` drops the methods the proposal removed.

**Embedder surface.** `OperationDeadlineConstraint` bounds a whole
multi-entry host operation; `ScriptPreparationOptions.StaticAnalysis`
trades prepare-time analysis for per-engine materialization on shared
graphs; `ModuleFactory.LocationOf` exposes the module-naming rule a host
must match; `Engine.Advanced.HostDefined` carries per-request state on a
pooled engine; the CLR exception behind an interop error is reachable
through `JintException.TryGetClrException` with opt-in
`ChainClrExceptions()`, and a host method's own `TargetException` is no
longer mistaken for a receiver mismatch; and a recursion-limit failure
propagates out of a module load instead of becoming a catchable
rejection.

**Performance, gated.** Against v4.15.3 on idle hardware, medians of
three paired runs: `controlflow-recursive` **−15.6% time and −40.4%
allocation** (proper tail calls), `bitops-3bit-bits-in-byte` −8.9%,
`math-spectral-norm` −7.3%, `crypto-sha1` −6.9%, `3d-raytrace` −5.9%,
`math-cordic` −5.8%, with a broad −1–4% tail across the call- and
string-heavy rows; no row moved outside its own measured cross-run
envelope in the other direction, and allocation is flat within ±0.2%
suite-wide. Warmed `parseInt` call sites take the frameless fast-call
lane (−13% on the parse loop), joined by the `Number` predicates,
`String.prototype.indexOf`/`startsWith`/`endsWith`/`includes`/`at`/`substr`,
global `isNaN`/`isFinite` and `Array.isArray` (−3% to −19%) and the
`Map`/`Set` method family (`map.get` hit loop −13%); existence questions
on a wrapped dictionary answer from `ContainsKey`, taking `in` −33% with
−98% allocation and `Object.keys` −37%; resolving an inherited global no
longer allocates per miss (−99.99% on the read loop) and a global
created through an inherited write keeps the in-place store; JSON
replacer/reviver eligibility is decided once per document, built-in
callback dispatch once per loop, a call site's arguments reach an
interpreted callee in registers, and function-local `let`/`const` live
in fixed slots.

**Breaking changes.**
`Int32Extensions`/`Int64Extensions`/`DoubleExtensions` — polyfill hosts
that leaked into the public API — are now internal; on
net462/netstandard2.0, code with `using Jint;` may have bound span
`Parse`/`TryParse` members through them. `JsonParser` rejects trailing
commas. `Number.parseInt.length`/`Number.parseFloat.length` report their
spec values. Post-construction mutation of an `Options` instance no
longer reaches an already-built engine, and `Options.Configure`
callbacks work again. `UnwrapIfPromise` reports a cancelled engine as
`ExecutionCanceledException` instead of a timeout. Time-zone matching is
ASCII-case-insensitive per ECMA-402.

On the [engine comparison
benchmarks](https://github.com/sebastienros/jint/blob/main/Jint.Benchmark/README.md),
Jint 4.16.0 is the fastest engine outright on 5 of 12 scripts — leading
`dromaeo-object-regexp-modern` over native V8 by 1.25× — in a
statistical tie for first on `interop-collection-traversal`, the fastest
managed engine on 10 of 12, the fastest interpreter on all 12, and
8.6×–11.2× ahead of ClearScript (native V8) on every interop row while
allocating 3.9×–12.4× less than the nearest managed competitor.

## What's Changed
* Run the repository's own host tests under Release-mode contract
verification by @​lahma in
sebastienros/jint#2866
* Update test262 suite and implement Iterator.prototype.join by @​lahma
in sebastienros/jint#2867
* Stop a closing iterator from swallowing the error that closed it by
@​lahma in sebastienros/jint#2868
* Give every benchmark row its own engine by @​lahma in
sebastienros/jint#2873
* Hand a call site's arguments to an interpreted callee in registers by
@​lahma in sebastienros/jint#2874
* Ask once per loop, not once per element, how to call a built-in's
callback by @​lahma in sebastienros/jint#2876
* Say which spec document to read for a feature by @​lahma in
sebastienros/jint#2882
* Run PR CI on every pull request, not only those targeting main by
@​lahma in sebastienros/jint#2883
* Update test262 suite and adopt the new take/drop RangeError by @​lahma
in sebastienros/jint#2877
* Implement Iterator Chunking by @​lahma in
sebastienros/jint#2878
* Say to write against the modern BCL and polyfill downwards by @​lahma
in sebastienros/jint#2884
* Implement Iterator Includes by @​lahma in
sebastienros/jint#2879
* Ask once per document, not once per key, how to call a JSON replacer
or reviver by @​lahma in sebastienros/jint#2885
* Use double.IsFinite instead of hand-rolled NaN and infinity pairs by
@​lahma in sebastienros/jint#2880
* Bump the testing group with 1 update by @​dependabot[bot] in
sebastienros/jint#2889
* Stop charging closures for per-engine lazy globals by @​lahma in
sebastienros/jint#2890
* Bump the analyzers group with 1 update by @​dependabot[bot] in
sebastienros/jint#2888
* Make the lazy-global re-arm on restore a contract instead of an
accident by @​lahma in sebastienros/jint#2892
* Let the amortized constraint cadence span top-level entries into the
engine by @​lahma in sebastienros/jint#2886
* Let a function's own let/const live in its fixed slots by @​lahma in
sebastienros/jint#2887
* Keep a host function in the engine's own realm after a second realm
exists by @​lahma in sebastienros/jint#2893
* Make an engine's principal realm [[HostDefined]] reachable by @​lahma
in sebastienros/jint#2891
* Carry a labelled break/continue target on the completion record by
@​lahma in sebastienros/jint#2894
* Make Array.prototype.sort stable on every target framework by @​lahma
in sebastienros/jint#2898
* Stop toSorted and %TypedArray%.sort hanging on an inconsistent
comparator by @​lahma in sebastienros/jint#2899
* Delete the evaluation context's dead completion channel by @​lahma in
sebastienros/jint#2895
* Stop shipping the numeric polyfill hosts as public API by @​lahma in
sebastienros/jint#2901
 ... (truncated)

Commits viewable in [compare
view](sebastienros/jint@v4.15.3...v4.16.0).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.15.3&new-version=4.16.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
lahma added a commit to lahma/jint that referenced this pull request Aug 25, 2026
…portValue claims the engine

Two things a host reaching into a ShadowRealm had every reason to expect and did not get. Both were
found while making ShadowRealm.SetValue mirror Engine.SetValue in sebastienros#3321 and deliberately left out of
it; both are in the same twelve lines of the same class.

SetValue converted its argument against whichever realm the host called from - the principal one -
and installed the result on the shadow realm's global object, so the wrapper carried the principal
realm's Object.prototype and `company instanceof Object` inside the realm answered false. That is the
opposite of what a realm is for: CrossRealmAttributionTests already pins the rule for the other
direction, that a built-in attributes what it produces to its own realm rather than to whichever realm
happens to be running.

Realm-scoped construction is entering that realm's execution context, and nothing else. Engine.Realm
is ExecutionContext.Realm, and every interop construction reads it to pick a prototype -
JsValue.FromObject through ObjectInstance's base constructor, TypeReference.CreateTypeReference
through TypeReferencePrototype, DelegateWrapper outright. ShadowRealmImportValue in this same class
already does exactly that, so SetValue now brackets its registration the same way, through a
RealmScope that names it. Engine._realmInConstruction is not an alternative: neither nestable nor
exception-safe, and it means something else. All six overloads take the scope, the JsValue one
included, so the rule has no exceptions to remember.

ClrFunction(Engine, ...), HostFunction and Constructor(Engine, string) deliberately keep pinning
engine._originalIntrinsics (sebastienros#2893) and are untouched. The residual that leaves - the three members
ObjectWrapper's constructor builds eagerly through the first of those are principal-realm functions
inside a shadow realm, while every lazily resolved member is correct - is filed as sebastienros#3365.

ImportValue took no host-call reservation at all, while driving module loading, linking and
evaluation; only the tail drain claimed anything, and only for its own length. It needed nothing but
the using: the reservation is same-thread re-entrant, so every EnterHostCall further down nests
inside it, and it is not _hostEntryDepth, so nothing about constraint re-arming changes. It cannot
deadlock either - ShadowRealmImportValue returns a pending promise and RunAvailableContinuations runs
only what is queued. The script-facing ShadowRealm.prototype.importValue goes through
ShadowRealmPrototype and never through this method.

Tests are in Jint.Tests.PublicInterface: ShadowRealmValueRealmTests, one fact per overload family
plus the two guards, five of which fail on main; and
HostEngineConcurrencyTests.ConcurrentShadowRealmImportValueIsRejected, which parks a dedicated thread
inside IModuleLoader.LoadModule on a signal rather than a clock and fails on main with "no exception
was thrown".

Docs: v5-migration.md sections 4.22 and 4.23; Jint/Runtime/Interop/AGENTS.md gains the gotcha - a
wrapper's prototype comes from the running realm, there is one realm-scoped construction path, and
these three constructors deliberately do not follow it. UndocumentedPublicApi.txt 658 to 657, since
ImportValue is being changed and ships documented. No API baseline moves.

Fixes sebastienros#3324
Fixes sebastienros#3325

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit to lahma/jint that referenced this pull request Aug 25, 2026
…portValue claims the engine

Two things a host reaching into a ShadowRealm had every reason to expect and did not get. Both were
found while making ShadowRealm.SetValue mirror Engine.SetValue in sebastienros#3321 and deliberately left out of
it; both are in the same twelve lines of the same class.

SetValue converted its argument against whichever realm the host called from - the principal one -
and installed the result on the shadow realm's global object, so the wrapper carried the principal
realm's Object.prototype and `company instanceof Object` inside the realm answered false. That is the
opposite of what a realm is for: CrossRealmAttributionTests already pins the rule for the other
direction, that a built-in attributes what it produces to its own realm rather than to whichever realm
happens to be running.

Realm-scoped construction is entering that realm's execution context, and nothing else. Engine.Realm
is ExecutionContext.Realm, and every interop construction reads it to pick a prototype -
JsValue.FromObject through ObjectInstance's base constructor, TypeReference.CreateTypeReference
through TypeReferencePrototype, DelegateWrapper outright. ShadowRealmImportValue in this same class
already does exactly that, so SetValue now brackets its registration the same way, through a
RealmScope that names it. Engine._realmInConstruction is not an alternative: neither nestable nor
exception-safe, and it means something else. All six overloads take the scope, the JsValue one
included, so the rule has no exceptions to remember.

ClrFunction(Engine, ...), HostFunction and Constructor(Engine, string) deliberately keep pinning
engine._originalIntrinsics (sebastienros#2893) and are untouched. The residual that leaves - the three members
ObjectWrapper's constructor builds eagerly through the first of those are principal-realm functions
inside a shadow realm, while every lazily resolved member is correct - is filed as sebastienros#3365.

ImportValue took no host-call reservation at all, while driving module loading, linking and
evaluation; only the tail drain claimed anything, and only for its own length. It needed nothing but
the using: the reservation is same-thread re-entrant, so every EnterHostCall further down nests
inside it, and it is not _hostEntryDepth, so nothing about constraint re-arming changes. It cannot
deadlock either - ShadowRealmImportValue returns a pending promise and RunAvailableContinuations runs
only what is queued. The script-facing ShadowRealm.prototype.importValue goes through
ShadowRealmPrototype and never through this method.

Tests are in Jint.Tests.PublicInterface: ShadowRealmValueRealmTests, one fact per overload family
plus the two guards, five of which fail on main; and
HostEngineConcurrencyTests.ConcurrentShadowRealmImportValueIsRejected, which parks a dedicated thread
inside IModuleLoader.LoadModule on a signal rather than a clock and fails on main with "no exception
was thrown".

Docs: v5-migration.md sections 4.22 and 4.23; Jint/Runtime/Interop/AGENTS.md gains the gotcha - a
wrapper's prototype comes from the running realm, there is one realm-scoped construction path, and
these three constructors deliberately do not follow it. UndocumentedPublicApi.txt 658 to 657, since
ImportValue is being changed and ships documented. No API baseline moves.

Fixes sebastienros#3324
Fixes sebastienros#3325

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit that referenced this pull request Aug 25, 2026
…portValue claims the engine (#3367)

Two things a host reaching into a ShadowRealm had every reason to expect and did not get. Both were
found while making ShadowRealm.SetValue mirror Engine.SetValue in #3321 and deliberately left out of
it; both are in the same twelve lines of the same class.

SetValue converted its argument against whichever realm the host called from - the principal one -
and installed the result on the shadow realm's global object, so the wrapper carried the principal
realm's Object.prototype and `company instanceof Object` inside the realm answered false. That is the
opposite of what a realm is for: CrossRealmAttributionTests already pins the rule for the other
direction, that a built-in attributes what it produces to its own realm rather than to whichever realm
happens to be running.

Realm-scoped construction is entering that realm's execution context, and nothing else. Engine.Realm
is ExecutionContext.Realm, and every interop construction reads it to pick a prototype -
JsValue.FromObject through ObjectInstance's base constructor, TypeReference.CreateTypeReference
through TypeReferencePrototype, DelegateWrapper outright. ShadowRealmImportValue in this same class
already does exactly that, so SetValue now brackets its registration the same way, through a
RealmScope that names it. Engine._realmInConstruction is not an alternative: neither nestable nor
exception-safe, and it means something else. All six overloads take the scope, the JsValue one
included, so the rule has no exceptions to remember.

ClrFunction(Engine, ...), HostFunction and Constructor(Engine, string) deliberately keep pinning
engine._originalIntrinsics (#2893) and are untouched. The residual that leaves - the three members
ObjectWrapper's constructor builds eagerly through the first of those are principal-realm functions
inside a shadow realm, while every lazily resolved member is correct - is filed as #3365.

ImportValue took no host-call reservation at all, while driving module loading, linking and
evaluation; only the tail drain claimed anything, and only for its own length. It needed nothing but
the using: the reservation is same-thread re-entrant, so every EnterHostCall further down nests
inside it, and it is not _hostEntryDepth, so nothing about constraint re-arming changes. It cannot
deadlock either - ShadowRealmImportValue returns a pending promise and RunAvailableContinuations runs
only what is queued. The script-facing ShadowRealm.prototype.importValue goes through
ShadowRealmPrototype and never through this method.

Tests are in Jint.Tests.PublicInterface: ShadowRealmValueRealmTests, one fact per overload family
plus the two guards, five of which fail on main; and
HostEngineConcurrencyTests.ConcurrentShadowRealmImportValueIsRejected, which parks a dedicated thread
inside IModuleLoader.LoadModule on a signal rather than a clock and fails on main with "no exception
was thrown".

Docs: v5-migration.md sections 4.22 and 4.23; Jint/Runtime/Interop/AGENTS.md gains the gotcha - a
wrapper's prototype comes from the running realm, there is one realm-scoped construction path, and
these three constructors deliberately do not follow it. UndocumentedPublicApi.txt 658 to 657, since
ImportValue is being changed and ships documented. No API baseline moves.

Fixes #3324
Fixes #3325


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Sep 1, 2026
…builds eagerly, belong to that realm (backport of #3367 and #3325/#3365) (#3557)

Backport of the two ShadowRealm realm-affinity fixes, adapted to 4.x. They land together because
the second only bites once the first exists: an ObjectWrapper takes its realm from whichever one is
running when it is built, so until SetValue enters the shadow realm there is no shadow realm for
the eagerly built members to belong to.

SetValue converted its argument against whichever realm the host called from - the principal one -
and then installed the result on the shadow realm's global object, so the wrapper carried the
principal realm's Object.prototype and `company instanceof Object` inside the realm answered false.
That is the opposite of what a realm is for. Realm-scoped construction is entering that realm's
execution context and nothing else: Engine.Realm is ExecutionContext.Realm, and every interop
construction reads it to pick a prototype - JsValue.FromObject through ObjectInstance's base
constructor, TypeReference.CreateTypeReference through TypeReferencePrototype, DelegateWrapper
outright. ShadowRealmImportValue in this same class already does exactly that, so SetValue now
brackets its registration the same way through a RealmScope that names it.

Three overloads take the scope on 4.x rather than main's six, and that is the same rule rather than
a narrower one: 4.x has no SetValue(string, Type), no generic and no array overload, so a Type and
an array both arrive through SetValue(string, object), and the four primitive overloads reach the
JsValue one, which takes the scope as well. Installation happening in the realm being written is
one rule rather than two.

ObjectWrapper's constructor then builds three members eagerly - Symbol.dispose, Symbol.asyncDispose
and toJSON - through the public ClrFunction(Engine, string, ...) constructor, which pins
engine._originalIntrinsics. That pin is deliberate and is what #2893 fixed: a function a *host*
wires up against an engine must belong to the realm the surrounding script can reach whatever realm
happened to be current when it was built. It is the wrong constructor for a function the engine
builds for an object it is creating, and the consequence was two answers on one object inside a
shadow realm - `handle.Dispose instanceof Function` true while
`handle[Symbol.dispose] instanceof Function` was false. Each now uses the internal
ClrFunction(Engine, Realm, ...) constructor whose own doc comment names this case, with
engine.Realm - the same realm ObjectInstance's constructor just took the object's own prototype
from. ClrFunction(Engine, ...), HostFunction and Constructor(Engine, string) are untouched, and
HostClrFunctionRealmTests still passes.

The other half of #3367 - ImportValue taking a host-call reservation - is deliberately not here:
4.x has no EnterHostCall and no engine-ownership reservation at all, so there is nothing to claim
and no HostEngineConcurrencyTests to join.

Tests are Jint.Tests.PublicInterface/ShadowRealmValueRealmTests, translated to xUnit: one fact per
converting overload, one per eagerly built member, both negative halves stated against the
principal intrinsic handed into the realm through the JsValue overload, the two guards that an
engine registration keeps the principal realm, and the control that disposal itself still works.
Failing first on unfixed 4.x: 10 of 13 on net10.0, 9 of 12 on net472.


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant